Java内存模型与线程

Java内存模型

主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。 memory_model.png

内存间交互操作

lock,unlock,read,load,use,assign,store,write 这8种操作都是原子的,不可再划分的。

对于volatile型变量的特殊规则

  • 第一项是保证此变量对所有线程的可见性(但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的)
  • 第二个语义是禁止指令重排序优化

volatile 的操作是针对的重排序优化是机器级的优化操作,所以字节码看不出来效果的。

原子性、可见性与有序性

  • 原子性:操作不可分割 synchronized
  • 可见性:可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改
    • final 被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸)
    • synchronized 同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”
    • volatile
  • 有序性:
    • volatile 关键字本身就包含了禁止指令重排序的语义
    • synchronized 一个变量在同一个时刻只允许一条线程对其进行lock操作,因此持有同一个锁的两个同步块只能串行地进入

线程安全与锁优化

当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

Java语言中的线程安全

不可变

不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。Java语言中,如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。

绝对线程安全

在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。例如 Vector,虽然它所有的对外暴露方法都用synchronized来进行修饰。但是在多线程的环境中,如果不在方法调用端做额外的同步措施,对Vector的操作依然不会是线程安全的。

相对线程安全

相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的。Vector、HashTable等。

线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。我们平常说一个类不是线程安全的,通常就是指这种情况。ArrayList和HashMap等。

线程对立

线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。

线程安全的实现方法

互斥同步

synchronized

一种最常见也是最主要的并发正确性保障手段。在Java里面,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。

在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。可以得到如下推论:

  • 被synchronized修饰的同步块对同一条线程来说是可重入的。
  • 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。(无法强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出)
Lock与ReentrantLock

Lock接口便成了Java的另一种全新的互斥同步手段。基于Lock接口,用户能够以非块结构(Non-Block Structured)来实现互斥同步。重入锁(ReentrantLock)是Lock接口最常见的一种实现,ReentrantLock也与synchronized很相似,主要增加了一些高级功能,有以下三项:等待可中断、可实现公平锁及锁可以绑定多个条件。

  • 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。
  • 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。

非阻塞同步

互斥同步属于一种悲观的并发策略,其总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享的数据是否真的会出现竞争,它都会进行加锁。这将会导致用户态到核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。

非阻塞同步:基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。

需要使用硬件的指令集来保证,常用的有:

  • 测试并设置(Test-and-Set);

  • 获取并增加(Fetch-and-Increment);

  • 交换(Swap);

  • 比较并交换(Compare-and-Swap,下文称CAS);

  • 加载链接/条件储存(Load-Linked/Store-Conditional,下文称LL/SC)

CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。

缺陷:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,那就能说明它的值没有被其他线程改变过了吗?这是不能的,因为如果在这段期间它的值曾经被改成B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA问题”。为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性。

无同步方案

可重入代码(Reentrant Code)

不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等。

线程本地存储(Thread Local Storage)

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。

锁优化

自旋锁与自适应自旋

为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。

锁粗化

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

results matching ""

    No results matching ""